Skip to content

Add basic <symbol> support in SVG #1819

Open
Theo1335 wants to merge 15 commits into
py-pdf:masterfrom
Theo1335:svg-feature
Open

Add basic <symbol> support in SVG #1819
Theo1335 wants to merge 15 commits into
py-pdf:masterfrom
Theo1335:svg-feature

Conversation

@Theo1335
Copy link
Copy Markdown

@Theo1335 Theo1335 commented Apr 15, 2026

Add basic support in SVG parsing

This PR adds support for SVG <symbol> elements.

  • Reuse existing group parsing logic for symbols.
  • Add a unit test covering symbol parsing and cross-reference registration.

Checklist:

  • A unit test is covering the code added / modified by this PR

  • In case of a new feature, docstrings have been added, with also some documentation in the docs/ folder (N/A)

  • A mention of the change is present in CHANGELOG.md

  • This PR is ready to be merged

By submitting this pull request, I confirm that my contribution is made under the terms of the GNU LGPL 3.0 license.

@andersonhc
Copy link
Copy Markdown
Collaborator

Thank you for this PR @Theo1335 , it's looking very promising!

I found one problem testing this SVG

image

The symbols are rendered but the scale is wrong.

Can you please check how to fix this?

@Theo1335
Copy link
Copy Markdown
Author

Theo1335 commented Apr 21, 2026

Thank you for your feedback @andersonhc !

I think I've finally solved the scaling issue with <symbol> elements: the viewBox is now extracted and stored when <symbol> is parsed, and when a <use> directive references a symbol with a width and height, the appropriate scaling is applied based on the `viewBox's dimensions.

Please let me know what you think.

Comment thread fpdf/svg.py Outdated
Comment on lines +1515 to +1520
viewbox = symbol.attrib.get("viewBox")
if viewbox:
parts = viewbox.replace(",", " ").split()
if len(parts) >= 4:
setattr(group, "_vw", float(parts[2]))
setattr(group, "_vh", float(parts[3]))
Copy link
Copy Markdown
Collaborator

@andersonhc andersonhc Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now the test is failing:

    @force_nodocument
    def build_symbol(self, symbol: "Element") -> GraphicsContext:
        """Parse <symbol> as reusable content, not rendered directly."""
        group = self.build_group(symbol)
        viewbox = symbol.attrib.get("viewBox")
        if viewbox:
            parts = viewbox.replace(",", " ").split()
            if len(parts) >= 4:
>               setattr(group, "_vw", float(parts[2]))
E               AttributeError: 'GraphicsContext' object has no attribute '_vw'

I don't think adding the viewbox attribute to the GraphicsContext is the best approach.
I recommend looking into using the GaphicsContext's transform to apply the viewbox.

Apply translate(-vx, -vy) @ scale(1 / vw, 1 / vh) to the symbol group should make it normalized to unit coordinates. In build_xref(), apply scale(width, height) @ translate(x, y).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @andersonhc , I've implemented your suggestion using transforms. The viewBox is now applied in build_symbol with scale(1/vw, 1/vh) @ translate(-vx, -vy), and build_xref applies scale(width, height) when specified. I also ignore percentage values for width/height. I tested it and it should be working now. Let me know what you think !

@Theo1335 Theo1335 requested a review from andersonhc May 13, 2026 00:07
Copy link
Copy Markdown
Collaborator

@andersonhc andersonhc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see suggested changes.

I would also add you to add a new item on test/svg/parameters.py on the test_svg_sources set:

pytest.param(svgfile("symbol.svg"), id="symbol reused in different placements"),

then create the file test/svg/svg_sources/symbol.svg with the content below:

<svg viewBox="0 0 80 20" xmlns="http://www.w3.org/2000/svg">
  <!-- Our symbol in its own coordinate system -->
  <symbol id="myDot" width="10" height="10" viewBox="0 0 2 2">
    <circle cx="1" cy="1" r="1" />
  </symbol>

  <!-- A grid to materialize our symbol positioning -->
  <path
    d="M0,10 h80 M10,0 v20 M25,0 v20 M40,0 v20 M55,0 v20 M70,0 v20"
    fill="none"
    stroke="pink" />

  <!-- All instances of our symbol -->
  <use href="#myDot" x="5" y="5" opacity="1.0" />
  <use href="#myDot" x="20" y="5" opacity="0.8" />
  <use href="#myDot" x="35" y="5" opacity="0.6" />
  <use href="#myDot" x="50" y="5" opacity="0.4" />
  <use href="#myDot" x="65" y="5" opacity="0.2" />
</svg>

After that you will need to execute test_svg_conversion() once adding generate=True to assert_pdf_equal so it can generate the new PDF reference file.

Comment thread fpdf/svg.py
"turn": math.tau,
}


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class SymbolInfo(NamedTuple):
"""Reusable SVG <symbol> sizing metadata."""
viewbox: tuple[float, float, float, float]
width: Optional[float]
height: Optional[float]
@force_nodocument
def resolve_optional_length(length_str: Optional[str]) -> Optional[float]:
"""Resolve an optional absolute SVG length, ignoring unsupported percentages."""
if not length_str or "%" in length_str:
return None
return resolve_length(length_str)
@force_nodocument
def parse_viewbox(viewbox: str) -> tuple[float, float, float, float]:
"""Parse an SVG viewBox into min-x, min-y, width, and height values."""
parts = [float(num) for num in NUMBER_SPLIT.split(viewbox.strip()) if num]
if len(parts) != 4:
raise ValueError(f"invalid viewBox {viewbox}")
vx, vy, vw, vh = parts
if (vw < 0) or (vh < 0):
raise ValueError(f"invalid negative width/height in viewBox {viewbox}")
return vx, vy, vw, vh

Comment thread fpdf/svg.py
) -> None:
self.image_cache = image_cache # Needed to render images
self.resource_access_policy = resource_access_policy
self.cross_references: dict[str, Any] = {}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.cross_references: dict[str, Any] = {}
self.symbol_info: dict[str, SymbolInfo] = {}

Comment thread fpdf/svg.py
Comment on lines +1511 to +1524
@force_nodocument
def build_symbol(self, symbol: "Element") -> GraphicsContext:
"""Parse <symbol> as reusable content, not rendered directly."""
group = self.build_group(symbol)
viewbox = symbol.attrib.get("viewBox")
if viewbox:
parts = viewbox.replace(",", " ").split()
if len(parts) >= 4:
vx, vy, vw, vh = (float(p) for p in parts[:4])
group.transform = Transform.scaling(
x=1 / vw, y=1 / vh
) @ Transform.translation(x=-vx, y=-vy)
return group

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@force_nodocument
def build_symbol(self, symbol: "Element") -> GraphicsContext:
"""Parse <symbol> as reusable content, not rendered directly."""
group = self.build_group(symbol)
viewbox = symbol.attrib.get("viewBox")
if viewbox:
parts = viewbox.replace(",", " ").split()
if len(parts) >= 4:
vx, vy, vw, vh = (float(p) for p in parts[:4])
group.transform = Transform.scaling(
x=1 / vw, y=1 / vh
) @ Transform.translation(x=-vx, y=-vy)
return group
@force_nodocument
def build_symbol(self, symbol: "Element") -> GraphicsContext:
"""Parse <symbol> as reusable content, not rendered directly."""
group = self.build_group(symbol)
viewbox = symbol.attrib.get("viewBox")
if viewbox:
vx, vy, vw, vh = parse_viewbox(viewbox)
if vw == 0 or vh == 0:
group = GraphicsContext()
else:
group.transform = Transform.translation(
x=-vx, y=-vy
) @ Transform.scaling(x=1 / vw, y=1 / vh)
symbol_id = symbol.attrib.get("id")
if symbol_id:
symbol_key = (
"#" + symbol_id if not symbol_id.startswith("#") else symbol_id
)
self.symbol_info[symbol_key] = SymbolInfo(
viewbox=(vx, vy, vw, vh),
width=resolve_optional_length(symbol.attrib.get("width")),
height=resolve_optional_length(symbol.attrib.get("height")),
)
return group

Comment thread fpdf/svg.py
Comment on lines +1544 to +1545
target = self.cross_references[ref]
pdf_group.add_item(target)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
target = self.cross_references[ref]
pdf_group.add_item(target)
pdf_group.add_item(self.cross_references[ref])

I don't think there's the need for the extra step here

Comment thread fpdf/svg.py
Comment on lines 1551 to +1569
if "x" in xref.attrib or "y" in xref.attrib:
# Quoting the SVG spec - 5.6.2. Layout of re-used graphics:
# > The x and y properties define an additional transformation translate(x,y)
x, y = float(xref.attrib.get("x", 0)), float(xref.attrib.get("y", 0))
pdf_group.transform = Transform.translation(x=x, y=y)
# Note that we currently do not support "width" & "height" in <use>
# Note that we currently do not support "width" & "height" with % in <use>

if "width" in xref.attrib or "height" in xref.attrib:
w_str = xref.attrib.get("width", "")
h_str = xref.attrib.get("height", "")
if "%" not in w_str and "%" not in h_str:
w = float(w_str) if w_str else 1
h = float(h_str) if h_str else 1
if pdf_group.transform is None:
pdf_group.transform = Transform.scaling(x=w, y=h)
else:
pdf_group.transform = (
Transform.scaling(x=w, y=h) @ pdf_group.transform
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
)
placement_transform = None
if "x" in xref.attrib or "y" in xref.attrib:
# Quoting the SVG spec - 5.6.2. Layout of re-used graphics:
# > The x and y properties define an additional transformation translate(x,y)
x, y = float(xref.attrib.get("x", 0)), float(xref.attrib.get("y", 0))
placement_transform = Transform.translation(x=x, y=y)
# Note that we currently do not support "width" & "height" with % in <use>
symbol_info = self.symbol_info.get(ref)
if symbol_info:
_, _, vw, vh = symbol_info.viewbox
width = (
resolve_optional_length(xref.attrib.get("width"))
or symbol_info.width
or vw
)
height = (
resolve_optional_length(xref.attrib.get("height"))
or symbol_info.height
or vh
)
symbol_transform = Transform.scaling(x=width, y=height)
if placement_transform is None:
placement_transform = symbol_transform
else:
placement_transform = symbol_transform @ placement_transform
if placement_transform:
if pdf_group.transform:
pdf_group.transform = placement_transform @ pdf_group.transform
else:
pdf_group.transform = placement_transform

Comment thread test/svg/test_svg.py
Comment on lines +249 to +262
def test_svg_symbol(self):
svg_data = (
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
"<defs>"
'<symbol id="rond" width="10" height="10" viewBox="0 0 2 2"><circle cx="1" cy="1" r="1" fill="red"/></symbol>'
"</defs>"
'<use href="#rond" x="10" y="10" width="40" height="40"/>'
"</svg>"
)
svg = fpdf.svg.SVGObject(svg_data)
assert svg is not None
assert "#rond" in svg.cross_references

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def test_svg_symbol(self):
svg_data = (
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
"<defs>"
'<symbol id="rond" width="10" height="10" viewBox="0 0 2 2"><circle cx="1" cy="1" r="1" fill="red"/></symbol>'
"</defs>"
'<use href="#rond" x="10" y="10" width="40" height="40"/>'
"</svg>"
)
svg = fpdf.svg.SVGObject(svg_data)
assert svg is not None
assert "#rond" in svg.cross_references
def test_svg_symbol(self):
svg_data = (
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
"<defs>"
'<symbol id="rond" width="10" height="10" viewBox="0 0 2 2"><circle cx="1" cy="1" r="1" fill="red"/></symbol>'
"</defs>"
'<use href="#rond" x="10" y="10" width="40" height="40"/>'
"</svg>"
)
svg = fpdf.svg.SVGObject(svg_data)
assert svg is not None
assert "#rond" in svg.cross_references
def test_svg_symbol_uses_symbol_dimensions(self):
svg_data = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20">'
'<symbol id="myDot" width="10" height="10" viewBox="0 0 2 2">'
'<circle cx="1" cy="1" r="1" />'
"</symbol>"
'<use href="#myDot" x="5" y="5" />'
"</svg>"
)
svg = fpdf.svg.SVGObject(svg_data)
symbol_use = svg.base_group.path_items[0]
assert tuple(symbol_use.transform) == pytest.approx((10, 0, 0, 10, 5, 5))
assert tuple(symbol_use.path_items[0].transform) == pytest.approx(
(0.5, 0, 0, 0.5, 0, 0)
)
def test_svg_symbol_use_dimensions_override_symbol_dimensions(self):
svg_data = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20">'
'<symbol id="myDot" width="10" height="10" viewBox="0 0 2 2">'
'<circle cx="1" cy="1" r="1" />'
"</symbol>"
'<use href="#myDot" x="5" y="5" width="40" height="20" />'
"</svg>"
)
svg = fpdf.svg.SVGObject(svg_data)
symbol_use = svg.base_group.path_items[0]
assert tuple(symbol_use.transform) == pytest.approx((40, 0, 0, 20, 5, 5))
def test_svg_symbol_viewbox_origin_is_translated_before_scaling(self):
svg_data = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20">'
'<symbol id="myDot" width="10" height="10" viewBox="10 10 2 2">'
'<circle cx="11" cy="11" r="1" />'
"</symbol>"
'<use href="#myDot" x="5" y="5" />'
"</svg>"
)
svg = fpdf.svg.SVGObject(svg_data)
symbol_use = svg.base_group.path_items[0]
assert tuple(symbol_use.transform) == pytest.approx((10, 0, 0, 10, 5, 5))
assert tuple(symbol_use.path_items[0].transform) == pytest.approx(
(0.5, 0, 0, 0.5, -5, -5)
)
def test_use_width_height_do_not_scale_non_symbol_references(self):
svg_data = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20">'
'<defs><path id="path" d="M 0 0 L 1 2 Z"/></defs>'
'<use href="#path" x="5" y="5" width="40" height="20" />'
"</svg>"
)
svg = fpdf.svg.SVGObject(svg_data)
path_use = svg.base_group.path_items[0]
assert tuple(path_use.transform) == pytest.approx((1, 0, 0, 1, 5, 5))

@Theo1335
Copy link
Copy Markdown
Author

Thanks for the detailed feedback! I will implement your suggestions and get back to you once it's done, or if I don't understand some details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants